Skip to content

[Fizz] Allow Pending Work to Specialize Abort Reasons#36586

Open
gnoff wants to merge 1 commit into
facebook:mainfrom
gnoff:jstory/specialize-abort-reason
Open

[Fizz] Allow Pending Work to Specialize Abort Reasons#36586
gnoff wants to merge 1 commit into
facebook:mainfrom
gnoff:jstory/specialize-abort-reason

Conversation

@gnoff
Copy link
Copy Markdown
Collaborator

@gnoff gnoff commented May 31, 2026

Allow Pending Work to Specialize Abort Reasons

Summary

Fizz currently reports every unfinished task using the same request-wide abort reason. This makes it possible to observe that a render did not finish, but not to understand why any individual suspended slot remained incomplete.

This change allows suspended tasks to report a more specific rejection reason when the wakeable they are blocked on rejects after abort begins and before Fizz finalizes that task. Tasks that do not reject during this window continue to report the general abort reason.

This is primarily motivated by partial prerendering, where aborting is not merely an exceptional termination mechanism. It is the API used to intentionally finish a prerender while leaving some work unresolved.

Motivation

For an ordinary server render, a request abort generally means that the result is no longer needed. Reporting the same abort reason for every unfinished task is usually sufficient.

For a partial prerender, the meaning is different. The caller intentionally aborts in order to produce a partial result. The unfinished work is then useful information: it identifies which parts of the tree prevented the prerender from completing.

Today, all of those tasks receive the same abort reason:

slot A -> prerender aborted
slot B -> prerender aborted
slot C -> prerender aborted

That says which work was incomplete, but not whether different slots should be interpreted differently.

For example, an application may have:

  • A slow API that is permitted to miss the prerender deadline and should not produce actionable logging.
  • Other work that is expected to finish during prerendering and should be reported when it does not.
  • A data source that can provide additional telemetry about why it did not finish once it learns that the prerender has been aborted.

With a single request-wide abort reason, onError cannot distinguish these cases.

Proposed Behavior

When abort begins, Fizz still associates a general abort reason with the request. That reason remains the fallback for every unfinished task.

However, if a task is suspended on a wakeable and that wakeable rejects during the interval between:

  1. The request beginning to abort.
  2. Fizz finalizing that task as aborted.

then Fizz reports the wakeable's rejection reason for that task instead of the general abort reason.

Conceptually:

slot A -> rejected during abort with TimeoutError("optional recommendations timed out")
slot B -> still pending when abort finishes -> Error("prerender deadline reached")
slot C -> rejected during abort with QueryError("inventory lookup canceled")

A rejection that arrives after its task has already been finalized is ignored.

Intended Usage

The canonical usage is for the caller to use the same AbortSignal both to terminate the prerender and to notify data sources that may still be blocking suspended work.

const controller = new AbortController();

const result = prerender(<App signal={controller.signal} />, {
  signal: controller.signal,
  onError(error) {
    if (controller.signal.aborted) {
      // Interpret errors from unfinished work after the prerender deadline.
    } else {
      // Interpret ordinary rendering errors.
    }
  },
});

controller.abort(new Error('prerender deadline reached'));

An interested data source can observe that signal and reject pending work with a reason specific to the blocked operation:

signal.addEventListener('abort', () => {
  reject(new TimeoutError('optional recommendations timed out'));
});

Tasks blocked on such work can now report that specialized reason. Tasks blocked on work that does not participate in the abort still report the request-wide abort reason.

This allows applications to:

  • Suppress logging for intentionally optional or deadline-limited work.
  • Surface unfinished work that should be investigated.
  • Include operation-specific telemetry or context in aborted-slot reporting.

Causality And Scope

Fizz does not attempt to prove that a wakeable rejected because of the abort signal.

The precise behavior is temporal:

  • If a suspended wakeable rejects after abort begins and before its task is finalized, its rejection specializes that task's abort reason.
  • If it does not reject during that interval, the task receives the general abort reason.
  • If it rejects after finalization, the rejection is ignored for Fizz error reporting.

Using the same AbortSignal to notify data sources is the intended protocol, but Fizz cannot distinguish a rejection caused by that signal from any unrelated rejection that happens to occur during the abort window.

Likewise, signal.aborted in onError lets callers distinguish errors observed before abort initiation from errors observed after it began. It does not independently prove causality for an arbitrary rejection.

Implementation

Previously, a suspended task attached the same ping callback for both fulfillment and rejection:

wakeable.then(ping, ping);

That is correct during ordinary rendering because retrying the task allows a rejected wakeable to throw through the normal render path, preserving regular error handling and stack construction.

During abort, however, retrying general work is intentionally suppressed. To preserve a rejection that arrives during the abort window, the task now stores distinct fulfillment and rejection ping callbacks:

wakeable.then(ping.resolve, ping.reject);

Before abort begins, ping.reject retains existing behavior by scheduling the task for retry.

After abort begins, ping.reject attempts to claim the still-pending aborted task from its owning abort set. If successful, Fizz finalizes that task immediately using the rejection reason. The later scheduled abort finish processes only tasks that remain in their abort sets, using the general abort reason.

This avoids adding another top-level property to Task, whose production shape is already at the current field-count threshold, while also covering suspension mechanisms such as React.lazy that cannot be handled by inspecting use() thenable state.

Tests

The tests cover:

  • A rejected suspended task reporting a specialized reason while unrelated pending work still reports the general abort reason.
  • Specialization for React.lazy, ensuring this is not limited to use() suspension.
  • A rejection arriving after abort finalization being ignored.
  • The prerender scheduling window in both static Browser and Node APIs, where abort listeners can reject pending work before abort completion.

@meta-cla meta-cla Bot added the CLA Signed label May 31, 2026
@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label May 31, 2026
@react-sizebot
Copy link
Copy Markdown

react-sizebot commented May 31, 2026

Comparing: 557e28f...41f759b

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.05% 1.88 kB 1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 614.26 kB 614.26 kB = 108.57 kB 108.57 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.05% 1.88 kB 1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 680.19 kB 680.19 kB = 119.51 kB 119.51 kB
facebook-www/ReactDOM-prod.classic.js = 700.61 kB 700.61 kB = 123.09 kB 123.09 kB
facebook-www/ReactDOM-prod.modern.js = 690.93 kB 690.93 kB = 121.48 kB 121.48 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable-semver/react-server/cjs/react-server.production.js +0.36% 148.77 kB 149.31 kB +0.33% 26.10 kB 26.19 kB
oss-stable/react-server/cjs/react-server.production.js +0.36% 148.77 kB 149.31 kB +0.33% 26.10 kB 26.19 kB
oss-experimental/react-server/cjs/react-server.production.js +0.35% 152.31 kB 152.85 kB +0.26% 26.89 kB 26.96 kB
oss-stable-semver/react-server/cjs/react-server.development.js +0.35% 214.63 kB 215.37 kB +0.26% 37.65 kB 37.75 kB
oss-stable/react-server/cjs/react-server.development.js +0.35% 214.63 kB 215.37 kB +0.26% 37.65 kB 37.75 kB
oss-experimental/react-server/cjs/react-server.development.js +0.34% 219.42 kB 220.16 kB +0.24% 38.58 kB 38.67 kB
oss-experimental/react-markup/cjs/react-markup.production.js +0.20% 249.68 kB 250.19 kB +0.15% 46.25 kB 46.32 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.browser.production.js +0.20% 252.56 kB 253.07 kB +0.15% 45.45 kB 45.52 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.browser.production.js +0.20% 252.59 kB 253.09 kB +0.15% 45.47 kB 45.54 kB

Generated by 🚫 dangerJS against 41f759b

Copy link
Copy Markdown
Collaborator

@unstubbable unstubbable left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice how simple and clean the final commit is because of the preparatory work that went into the preceding PRs.

One (possibly naïve) question about the causality:

Using the same AbortSignal to notify data sources is the intended protocol, but Fizz cannot distinguish a rejection caused by that signal from any unrelated rejection that happens to occur during the abort window.

What if we made the contract so that a rejected wakeable would need to use the abort signal's reason as its cause? That would allow us to ignore unrelated rejections that just coincidentally happened to occur between starting and finishing the abort, wouldn't it?

E.g. (building on your "Intended Usage" example):

signal.addEventListener('abort', () => {
  reject(new TimeoutError('optional recommendations timed out', {cause: signal.reason}));
});

And then in pingRejectedTask we could compare error.cause with request.fatalError, I suppose.

Credok12

This comment was marked as spam.

@gnoff
Copy link
Copy Markdown
Collaborator Author

gnoff commented Jun 1, 2026

@unstubbable

What if we made the contract so that a rejected wakeable would need to use the abort signal's reason as its cause? That would allow us to ignore unrelated rejections that just coincidentally happened to occur between starting and finishing the abort, wouldn't it?

I had considered that. It's attractive because it provides more certainty about the provenance. It does limit what you can specialize with to errors and error-like objects. I.e. you can't specialize to a string. But that's probably not of much practical concern.

Interestingly it seems fetch will reject the fetch with the AbortSignal's reason without a wrapper Error so if this pattern is common in the wild it may be a noop functionally since the lack of cause will still be interpretted as rejecting for the reason.

The reason I didn't implement it that way at first was just because it felt finicky if you end up forgetting to "link" the specialized reason to the original you lose out on that extra context. And if an unassociated error ends up sneaking in it doesn't seem harmful to misinterpret it as caused by the abort. If it arrived slightly sooner you'd see that error in the onError list. If it arrived slightly later you'd miss it entirely. The biggest risk is if it arrives in the gap and you use the status of "already aborted" to do something with that info that would be incorrect if it weren't really from the abort, but tbh I can't even think of a plausible scenario where that would be the case. And then on top of that if you really cared about this extra restriction you could enforce yourself in onError that the reason must have the abort reason as a cause otherwise you'll consider it a normal error.

Fizz currently reports every unfinished task using the request-wide abort reason. This is generally sufficient for ordinary renders, where aborting primarily means stopping output, but it is limiting for partial prerendering because abort is the API used to intentionally leave parts of the tree unfinished. Callers may want to treat a known slow optional API as an expected prerender miss while still surfacing other unfinished work as actionable feedback, or allow a data source to provide operation-specific telemetry once it learns that prerendering was aborted.

This change lets a suspended task report the rejection from the wakeable it was blocked on when that rejection arrives after abort begins and before Fizz finalizes that task. Tasks that remain pending continue to report the request-wide abort reason, and rejections that arrive after finalization are ignored. The intended pattern is to use the same AbortSignal both to abort the prerender and to notify pending data sources, allowing those sources to reject with slot-specific reasons.

Fizz does not attempt to prove that a rejection was caused by the abort signal. Any suspended wakeable that rejects during the abort window can specialize the reason for its task. Callers can use signal.aborted in onError to distinguish ordinary render errors observed before abort from unfinished-work errors observed after abort begins, but this does not establish causality for arbitrary asynchronous rejections.

To support this without adding another top-level field to Task, ping now contains separate resolve and reject callbacks. Before abort begins, rejected wakeables retain the existing retry behavior so ordinary render errors continue through the normal error path. After abort begins, a rejected ping claims its still-pending task from its abort set and finalizes it using the rejection reason; the scheduled abort finish applies the general abort reason only to remaining tasks. This also covers suspension sources such as React.lazy, which cannot be handled by inspecting use() thenable state alone.

Tests cover specialization alongside unrelated pending work that retains the general abort reason, React.lazy specialization, dropping rejections that arrive after abort completion, and the prerender scheduling window that lets abort listeners reject pending work before abort finishes.
@gnoff gnoff force-pushed the jstory/specialize-abort-reason branch from 4a2589c to 41f759b Compare June 1, 2026 15:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants